Skip to content

fix: normalize lazy() module-url paths to forward slashes on Windows#263

Open
rhengles wants to merge 1 commit into
solidjs:nextfrom
arijs:fix/lazy-module-url-windows-backslash
Open

fix: normalize lazy() module-url paths to forward slashes on Windows#263
rhengles wants to merge 1 commit into
solidjs:nextfrom
arijs:fix/lazy-module-url-windows-backslash

Conversation

@rhengles

Copy link
Copy Markdown

fix: normalize lazy() module-url paths to forward slashes on Windows

Temporary file — draft body for the PR. Delete before/after opening.
Target branch: next (the lazy() module-url feature only exists on the 3.x line).

Problem

The solid-lazy-module-url babel pass detects lazy(() => import("specifier"))
and injects a placeholder as a 2nd argument; the transform hook then resolves
that placeholder to the module's project-relative path and substitutes it back
into the emitted source.

The path is built with path.relative, which returns OS-native separators.
On Windows that means backslashes, and the result is spliced straight back into
JS source as a string literal:

// emitted on Windows:
const App = lazy(() => import('./App'), "src\components\App.tsx");
//                                        ^^^^ \c, \A → invalid escape sequence

\c, \A, etc. are invalid string escape sequences, so the file fails to
parse. In practice this breaks dev and build for any lazy() route on
Windows
(the downstream esbuild/rolldown transform throws
Invalid escape sequence / Transform failed). POSIX is unaffected because
path.relative already returns forward slashes there — which is why it has
gone unnoticed.

Where

src/index.ts, in the lazy-placeholder resolution loop:

resolved: '"' + path.relative(projectRoot, cleanId) + '"',

No normalization is applied before the path is embedded back into source.

Fix

Normalize the resolved path to POSIX separators before embedding it. The path
is only ever used as a module-url string in generated JS, so forward slashes
are always correct (and match what the rest of Vite emits).

A small pure helper is added to src/lazy-module-url.ts (its natural home,
alongside LAZY_PLACEHOLDER_PREFIX):

export function normalizeLazyModulePath(relativePath: string): string {
  return relativePath.replace(/\\/g, '/');
}

and the call site becomes:

resolved: '"' + normalizeLazyModulePath(path.relative(projectRoot, cleanId)) + '"',

That is the entire behavioral change — one helper plus one wrapped call.

Tests

test/lazy-module-url.test.ts covers the helper with the built-in
node:test runner (no new dependencies):

  • Windows backslashes → forward slashes (the regression)
  • POSIX paths left untouched
  • mixed separators
  • bare filename (no separator) untouched

Why a unit test of the helper, and not an example/e2e test

The bug is OS-specific: it only reproduces when path.relative returns
backslashes, i.e. on Windows. The existing example + Cypress/Vitest suites run
on Linux CI runners, where path.relative already yields forward slashes —
so an example-based test could never fail for this bug regardless of the fix.

A pure unit test on the normalization function is deterministic on every
OS: it feeds a backslash path in and asserts forward slashes out, so it guards
the regression on the Linux CI too.

How it's wired (and why this way)

There is intentionally no new package.json script. The unit suite is run
from the existing scripts/test-examples.ts (already invoked by pnpm test),
before the example builds, so:

async function runUnitTests() {
  console.log('Running unit tests...');
  await new Promise<void>((resolve, reject) => {
    const proc = spawn('node', ['--test', 'test/**/*.test.ts'], { stdio: 'inherit' });
    activeProcesses.add(proc);
    proc.on('error', reject);
    proc.on('exit', (code: number | null) => {
      activeProcesses.delete(proc);
      code === 0 ? resolve() : reject(new Error(`Unit tests failed (exit code ${code})`));
    });
  });
}

Rationale:

  • Single entry point. pnpm test already drives the whole suite; folding
    the unit tests in keeps one command and means CI (e2e.ymlpnpm run test)
    picks them up with no workflow changes.
  • Fail-fast. Unit tests run first — fast and cheap — before the heavy
    pnpm install + build per example.
  • Streaming output. spawn(..., { stdio: 'inherit' }) lets the TAP output
    pass straight through, and a non-zero exit rejects so the run aborts (and the
    child is tracked in activeProcesses for the existing cleanup path).
  • Zero new deps / zero config. node:test is built in. Node strips the
    TypeScript types itself (CI runs Node 23, which does this unflagged; the
    test/ dir is outside tsconfig include and the rollup entry, so it never
    leaks into dist).

Placement / CI note

This targets next because lazy() module-url resolution only exists
there. Heads-up: the workflows currently trigger on branches: [main] only, so
a next-targeted PR may not auto-run CI until next is added to the triggers
(left untouched here — maintainer call).

Verification

  • node scripts/test-examples.tsRunning unit tests... → 4/4 pass →
    proceeds to Testing vite-3....
  • pnpm build → exit 0; bundle contains
    normalizeLazyModulePath(path.relative(projectRoot, cleanId)).
  • Manually reproduced on Windows: a lazy() route 500'd in dev with
    Invalid escape sequence before the change, renders cleanly after.

The `solid-lazy-module-url` transform appends the resolved module path as
the 2nd argument to `lazy(() => import(...))` using `path.relative`, which
yields backslashes on Windows. The injected `"src\components\App.tsx"` is an
invalid escape sequence that broke dev/build for any `lazy()` route on Windows.

Add a `normalizeLazyModulePath` helper, a node:test unit suite for it, and run
those unit tests from `scripts/test-examples.ts` (so `pnpm test` covers them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bb9f9bb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
vite-plugin-solid Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant